As you can see, my diabolical program accepts the heinous input text as a positional argument. Since this program uses the random module, I want to accept an -s or --seed option so I can replicate the vile output:
$ ./ransom.py --seed 3 'give us 2 million dollars or the cat gets it!' giVE uS 2 MILlioN dolLaRS OR tHe cAt GETS It!
If the unlawful program is run with no arguments, it should print a short, infernal usage statement:
$ ./ransom.py usage: ransom.py [-h] [-s int] text ransom.py: error: the following arguments are required: text
If the nefarious program is run with -h or --help flags, it should print a longer, fiendish usage:
$ ./ransom.py -h usage: ransom.py [-h] [-s int] text Ransom Note positional arguments: text Input text or file optional arguments: -h, --help show this help message and exit -s int, --seed int Random seed (default: None)
Figure 12.1 shows a noxious string diagram to visualize the inputs and outputs.
Figure 12.1 The awful program will transform input text into a ransom note by randomly capitalizing letters.
Learn how to use the random module to figuratively “flip a coin” to decide between two choices
Explore ways to generate new strings from an existing one, incorporating random decisions
Study the similarities of for loops, list comprehensions, and the map() function
I suggest starting with new.py or copying the template/template.py file to create ransom.py in the 12_ransom directory. This program, like several before it, accepts a required, positional string for the text and an optional integer (default None) for the --seed. Also, as in previous exercises, the text argument may name a file that should be read for the text value.
To start out, use this for your main() code:
def main():
args = get_args() ①
random.seed(args.seed) ②
print(args.text) ③
① Get the processed command-line arguments.
② Set the random.seed() with the value from the user. The default is None, which is the same as not setting it.
③ Start off by echoing back the input.
If you run this program, it should echo the input from the command line:
$ ./ransom.py 'your money or your life!' your money or your life!
Or the text from an input file:
$ ./ransom.py ../inputs/fox.txt The quick brown fox jumps over the lazy dog.
The important thing when writing a program is to take baby steps. You should run your program after every change, checking manually and with the tests to see if you are progressing.
Once you have this working, it’s time to think about how to randomly capitalize this awful message.
You’ve seen before that you can’t directly modify a str value:
>>> text = 'your money or your life!' >>> text[0] = 'Y' Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: 'str' object does not support item assignment
So how can we randomly change the case of some of the letters?
I suggest that instead of thinking about how to change many letters, you should think about how to change one letter. That is, given a single letter, how can you randomly return the upper- or lowercase version of the letter? Let’s create a dummy choose() function that accepts a single character. For now, we’ll have the function return the character unchanged:
def choose(char):
return char
def test_choose():
state = random.getstate() ①
random.seed(1) ②
assert choose('a') == 'a' ③
assert choose('b') == 'b'
assert choose('c') == 'C'
assert choose('d') == 'd'
random.setstate(state) ④
① The state of the random module is global to the program. Any change we make here could affect unknown parts of the program, so we save our current state.
② Set the random seed to a known value. This is a global change to our program. Any other calls to functions from the random module will be affected!
③ The choose() function is given a series of letters, and we use the assert statement to test if the value returned by the function is the expected letter.
④ Reset the global state to the original value.
Think about using an if expression where you return the uppercase answer when the 0 or False option is selected and the lowercase version otherwise. My entire choose() function is this one line.
Now we need to apply our choose() function to each character in the input string. I hope this is starting to feel like a familiar tactic. I encourage you to start by mimicking the first approach from chapter 8 where we used a for loop to iterate through each character of the input text and replace all the vowels with a single vowel. In this program, we can iterate through the characters of text and use them as the argument to the choose() function. The result will be a new list (or str) of the transformed characters. Once you can pass the test with a for loop, try to rewrite it as a list comprehension, and then a map().
Now off you go! Write the program, pass the tests.
We’re going to explore many ways to process all the characters in the input text. We’ll start off with a for loop that builds up a new list, and I hope to convince you that a list comprehension is a better way to do this. Finally, I’ll show you how to use map() to create a very terse (perhaps even elegant) solution.
#!/usr/bin/env python3
"""Ransom note"""
import argparse
import os
import random
# --------------------------------------------------
def get_args():
"""get command-line arguments"""
parser = argparse.ArgumentParser(
description='Ransom Note',
formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('text', metavar='text', help='Input text or file') ①
parser.add_argument('-s', ②
'--seed',
help='Random seed',
metavar='int',
type=int,
default=None)
args = parser.parse_args() ③
if os.path.isfile(args.text): ④
args.text = open(args.text).read().rstrip()
return args ⑤
# --------------------------------------------------
def main():
"""Make a jazz noise here"""
args = get_args()
text = args.text
random.seed(args.seed) ⑥
ransom = [] ⑦
for char in args.text: ⑧
ransom.append(choose(char)) ⑨
print(''.join(ransom)) ⑩
# --------------------------------------------------
def choose(char): ⑪
"""Randomly choose an upper or lowercase letter to return"""
return char.upper() if random.choice([0, 1]) else char.lower() ⑫
# --------------------------------------------------
def test_choose(): ⑬
"""Test choose"""
state = random.getstate() ⑭
random.seed(1) ⑮
assert choose('a') == 'a' ⑯
assert choose('b') == 'b'
assert choose('c') == 'C'
assert choose('d') == 'd'
random.setstate(state) ⑰
# --------------------------------------------------
if __name__ == '__main__':
main()
① The text argument is a positional string value.
② The --seed option is an integer that defaults to None.
③ Process the command-line arguments into the args variable.
④ If the args.text is a file, use the contents of that as the new args.text value.
⑤ Return the arguments to the caller.
⑥ Set the random.seed() to the given args.seed value. The default is None, which is the same as not setting it. That means the program will appear random when no seed is given but will be testable when we do provide a seed value.
⑦ Create an empty list to hold the new ransom message.
⑧ Use a for loop to iterate through each character of args.text.
⑨ Append the chosen letter to the ransom list.
⑩ Join the ransom list on the empty string to create a new string to print.
⑪ Define a function to randomly return the upper- or lowercase version of a given character.
⑫ Use random.choice() to select either 0 or 1, which, in the Boolean context of the if expression, evaluates to False or True, respectively.
⑬ Define a test_choose() function that will be run by Pytest. The function takes no arguments.
⑭ Save the current state of the random module.
⑮ Set the random.seed() to a known value for the purposes of the test.
⑯ Use the assert statement to verify that we get the expected result from the choose() for a known argument.
⑰ Reset the random module’s state so that our changes won’t affect any other part of the program.
Assume that we have the following cruel message:
>>> text = '2 million dollars or the cat sleeps with the fishes!'
I want to randomly upper- and lowercase the letters. As suggested in the earlier description of the problem, we can use a for loop to iterate over each character. One way to print an uppercase version of the text is to print an uppercase version of each letter:
for char in text:
print(char.upper(), end='')
Following the first solution from chapter 8, I created a new list to hold the ransom message and added these random choices:
ransom = []
for char in text:
if random.choice([False, True]):
ransom.append(char.upper())
else:
ransom.append(char.lower())
Then I joined the new characters on the empty string to print a new string:
print(''.join(ransom))
It’s far less code to write this with an if expression to select whether to take the upper- or lowercase character, as shown in figure 12.2:
ransom = []
for char in text:
ransom.append(char.upper() if random.choice([False, True]) else char.lower())
Figure 12.2 A binary if/else branch is more succinctly written using an if expression.
You don’t have to use actual Boolean values (False and True). You could use 0 and 1 instead:
ransom = []
for char in text:
ransom.append(char.upper() if random.choice([0, 1]) else char.lower())
When numbers are evaluated in a Boolean context (that is, in a place where Python expects to see a Boolean value), 0 is considered False, and every other number is True.
The if expression is a bit of code that could be put into a function. I find it hard to read inside the ransom.append().
By putting it into a function, I can give it a descriptive name and write a test for it:
def choose(char):
"""Randomly choose an upper or lowercase letter to return"""
return char.upper() if random.choice([0, 1]) else char.lower()
Now I can run the test_choose() function to test that my function does what I think. This code is much easier to read:
ransom = []
for char in text:
ransom.append(choose(char))
The solution in section 12.2 creates an empty list, to which I list.append() the return from choose(). Another way to write list.append() is to use the += operator to add the right-hand value (the element to add) to the left-hand side (the list), as in figure 12.3.
def main():
args = get_args()
random.seed(args.seed)
ransom = []
for char in args.text:
ransom += choose(char)
print(''.join(ransom))
Figure 12.3 The += operator is another way to write list.append().
This is the same syntax for concatenating a character to a string or adding a number to another number.
The two previous solutions require that the lists be joined on the empty string to make a new string to print. We could, instead, start off with an empty string and build that up, one character at a time, using the += operator:
def main():
args = get_args()
random.seed(args.seed)
ransom = ''
for char in args.text:
ransom += choose(char)
print(ransom)
As we just noted, the += operator is another way to append an element to a list. Python often treats strings and lists interchangeably, often implicitly, for better or worse.
The previous patterns all initialize an empty str or list and then build it up with a for loop. I’d like to convince you that it’s almost always better to express this using a list comprehension, because its entire raison d’être is to return a new list. We can condense our three lines of code to just one:
def main():
args = get_args()
random.seed(args.seed)
ransom = [choose(char) for char in args.text]
print(''.join(ransom))
Or you can skip creating the ransom variable altogether. As a general rule, I only assign a value to a variable if I use it more than once or if I feel it makes my code more readable:
def main():
args = get_args()
random.seed(args.seed)
print(''.join([choose(char) for char in args.text]))
A for loop is really for iterating through some sequence and producing side effects, like printing values or handling lines in a file. If your goal is to create a new list, a list comprehension is probably the best tool. Any code that would go into the body of the for loop to process an element is better placed in a function with a test.
I’ve mentioned before that map() is just like a list comprehension, though usually with less typing. Both approaches generate a new list from some iterable, as shown in figure 12.4. In this case, the resulting list from map() is created by applying the choose() function to each character of args.text:
def main():
args = get_args()
random.seed(args.seed)
ransom = map(choose, args.text)
print(''.join(ransom))
Figure 12.4 The ideas of the list comprehension can be expressed more succinctly with map().
Or, again, you could leave out the ransom assignment and use the list that comes back from map() directly:
def main():
args = get_args()
random.seed(args.seed)
print(''.join(map(choose, args.text)))
It may seem silly to spend so much time working through so many ways to solve what is essentially a trivial problem, but one of the goals of this book is to explore the various ideas available in Python. The first solution in section 12.2 is a very imperative solution that a C or Java programmer would probably write. The version using a list comprehension is very idiomatic to Python--it is “Pythonic,” as Pythonistas would say. The map() solution would look very familiar to someone coming from a purely functional language like Haskell.
All these approaches accomplish the same goal, but they embody different aesthetics and programming paradigms. My preferred solution would be the last one, using map(), but you should choose an approach that makes the most sense to you.
Write a version of ransom.py that represents letters in other ways by combining ASCII characters, such as the following. Feel free to make up your own substitutions. Be sure to update your tests.
A 4 K |< B |3 L |_ C ( M |\/| D |) N |\| E 3 P |` F |= S 5 G (- T + H |-| V \/ I 1 W \/\/ J _|